"use client"; /** * /w/:workspace/settings/billing — plan + usage. * * - Plan card (workspace.plan — the self-hosted build runs on "free"; there is * no Stripe/billing integration, so this is informational). * - Usage: GET /workspaces/:id/usage → { usage:{pages,databases,records, * storageBytes,apiKeys,webhooks}, limits:{…} }. `null` limit = unlimited. * * Self-hosted: the API returns a plan but has no payment surface, so "Manage" * is a no-op link to documentation rather than a billing portal. */ import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { CreditCard } from "lucide-react"; import { useWorkspace } from "@/hooks/use-workspace"; import { getWorkspaceUsage } from "@/lib/files/api"; import { SettingsShell, Section } from "@/components/settings/settings-shell"; import { cn } from "@/lib/utils"; function formatBytes(n: number): string { if (!n) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.min(units.length - 1, Math.floor(Math.log2(n) / 10)); return `${(n / 2 ** (10 * i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function barPct(used: number, limit: number | null): number | null { if (limit == null || limit <= 0) return null; return Math.min(100, Math.round((used / limit) * 100)); } export default function SettingsBillingPage() { const params = useParams<{ workspace: string }>(); const slugOrId = params?.workspace ?? ""; const { data: ws, isLoading } = useWorkspace(slugOrId); const usage = useQuery({ queryKey: ["workspaces", ws?.id ?? slugOrId, "usage"], queryFn: () => getWorkspaceUsage(ws?.id ?? slugOrId), enabled: !!ws?.id }); const u = usage.data?.usage; const l = usage.data?.limits; // The workspace object carries the plan as `billingPlan` (live API shape); // the older `plan` field is the web type's name. Prefer the real value. const planLabel = ( (ws as unknown as { billingPlan?: string } | null)?.billingPlan ?? ws?.plan ?? "free" ).toLowerCase(); // TASK-0104: the authoritative storage counter is `usage.storageBytes` // (BigInt → decimal STRING) vs the always-set `limits.maxStorageBytes`. const storageUsed = u ? Number(u.storageBytes) : 0; const storageLimit = l?.maxStorageBytes ?? null; const storagePct = storageLimit != null && storageLimit > 0 ? Math.min(100, (storageUsed / storageLimit) * 100) : null; const rows: { label: string; used: number; limit: number | null; format: (n: number) => string }[] = u ? [ { label: "Pages", used: u.pages, limit: l?.maxPages ?? null, format: (n) => `${n.toLocaleString()}` }, { label: "Databases", used: u.databases, limit: l?.maxDatabases ?? null, format: (n) => `${n.toLocaleString()}` }, { label: "Records", used: u.records, limit: l?.maxRecords ?? null, format: (n) => `${n.toLocaleString()}` }, { label: "Storage", used: storageUsed, limit: storageLimit, format: formatBytes }, { label: "API keys", used: u.apiKeys, limit: l?.maxApiKeys ?? null, format: (n) => `${n}` }, { label: "Webhooks", used: u.webhooks, limit: l?.maxWebhooks ?? null, format: (n) => `${n}` } ] : []; return ( {isLoading || !ws ? (

Loading…

) : (

{planLabel} plan

Self-hosted — no billing portal. Usage limits are configured by the administrator.

{/* TASK-0104: storage usage bar (authoritative counter + plan quota). */}
Storage used {usage.isLoading ? "…" : formatBytes(storageUsed)} {" / "} {storageLimit == null ? "unlimited" : formatBytes(storageLimit)} {storagePct != null ? ( = 90 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")}> {Math.round(storagePct)}% ) : null}
= 90 ? "bg-red-600" : storagePct >= 70 ? "bg-amber-500" : "bg-primary" )} style={{ width: `${storagePct ?? 0}%` }} />

{planLabel} plan quota.

    {rows.map((r) => { const pct = barPct(r.used, r.limit); return (
  • {r.label} {r.format(r.used)} / {r.limit == null ? "unlimited" : r.format(r.limit)}
    {pct != null && (
    = 90 ? "bg-destructive" : pct >= 70 ? "bg-amber-500" : "bg-primary"}`} style={{ width: `${pct}%` }} />
    )}
  • ); })}
{!usage.isLoading && rows.length === 0 && (

Usage data unavailable.

)}
)}
); }